C 기초 강의의 환경변수(11-01 ~ 11-03)와 시그널(12-01 ~ 12-04) 실습에서 만난 오류 패턴을 정리하고, 이를 바탕으로 로그 기록기(env_ex.c), PATH 탐색기(env_ex2.c), 시그널 카운터 두 개(sig_ex.c, sig_ex2.c)를 직접 만들며 겪은 버그를 기록한다.
환경은 Docker Desktop의 Linux 컨테이너(c_app_part1), gcc -Wall -Wextra.
IMPORTANT
코드는 컨테이너에서 컴파일·실행해 확인했고, 설명은 AI(Claude)와 함께 코드 리뷰하며 정리했다.
atexit(exit1);atexit(exit2);if (argc != 1) { _Exit(1); } // 핸들러가 실행되지 않는다return 0; // exit2 → exit1 순으로 실행 (LIFO)
atexit로 등록한 함수는 return(main)이나 exit()로 끝날 때 등록의 반대 순서로 실행된다.
_Exit()는 atexit 핸들러도 시그널 핸들러도 실행하지 않는다. abort()도 atexit 핸들러를 실행하지 않는다. stdio 버퍼를 비우는지는 구현마다 다르다 (아래 확인 참고). 자원 정리가 필요한 프로그램은 이 둘로 끝내지 않는다.
atexit 핸들러 안에서 exit()를 호출하거나, 시그널 핸들러 안에서 exit()를 호출하는 것은 정의되지 않은 동작이다 (교육자료 11-3). 핸들러에서 프로세스를 끝내려면 _Exit()(async-signal-safe) 또는 flag를 세우고 main이 정상 종료하게 한다.
컨테이너(glibc 2.17)에서 파일에 한 줄을 fprintf한 뒤 종료 방법만 바꿔 파일 크기를 확인했다: exit() 13바이트, abort() 13바이트, _Exit() 0바이트.
즉 이 환경에서 abort()는 열린 스트림을 비웠지만 _Exit()는 비우지 않았다.
glibc 2.27 이후의 abort()는 스트림을 비우지 않는 것으로 알려져 있어서(이 환경에서는 확인하지 못했다) abort()에 버퍼 flush를 기대하면 안 된다.
2. 실습 1 — 로그 기록기 env_ex.c
요구사항: LOG_PATH 환경변수로 로그 파일을 정하고(없으면 default.log), 입력받은 줄을 기록한다. q/quit으로 종료하고, 종료 시 atexit로 등록한 clean_up()에서 Program terminated normally를 쓰고 파일을 닫는다.
g_dirs는 문자열이 아니라 포인터 n개를 담는 배열이다 (malloc(sizeof(*g_dirs) * n)). 디렉토리 이름은 g_path_copy 안에 그대로 있고 g_dirs[i]가 거기를 가리킨다. 해제는 free(g_dirs), free(g_path_copy) 두 번이면 끝난다.
경로는 dir + '/' + command + '\0'만큼 길이를 계산해서 malloc한다.
access(full, X_OK)만으로는 디렉토리도 통과하므로 stat + S_ISREG로 일반 파일인지 확인한다.
핸들러는 atexit(free_all) → atexit(report_searched) 순으로 등록한다. 실행은 반대라서 개수 출력 후 해제가 된다.
인자는 cmd -a, -a cmd 모두 받는다. 오류는 종료 코드 2.
확인 결과
실행 결과 표
실행
결과
종료 코드
ls
/usr/bin/ls (4개 검색)
0
-a ls
/usr/bin/ls, /bin/ls (6개)
0
없는 명령어
출력 없음
1
인자 없음 / 모르는 옵션 / 명령어 2개
usage
2
PATH 없음
PATH is not set
2
PATH="/nonexistent::/usr/bin:"
빈 항목 2개가 .로 처리되어 ./env_ex2가 두 번
0
WARNING
exit 핸들러는 return/exit로 끝날 때만 실행된다. 어떤 경로로 종료돼도 실행하라는 요구사항을 지키려면 _Exit(), abort()를 쓰지 않는다.
void handler(int signum) { if (signum == SIGUSR1) { sig1 = 1; } else if (sig1) { sig2 = 1; } // SIGUSR1 이 먼저 처리돼 있어야만 SIGUSR2 를 인정}
의도는 SIGUSR1 → SIGUSR2 순서인데, 핸들러 실행 중에 같은 종류가 또 오거나 두 시그널이 가깝게 오면 일부가 사라진다. (같은 번호의 시그널은 대기 중이면 한 번으로 합쳐진다.)
sigaction으로 핸들러를 설치하고, sa_mask와 sigprocmask로 처리 중에 끼어들 시그널을 막는다.
signal()의 이식성 (12-02)
요약
문제: signal()은 구현마다 핸들러가 유지되거나 1회 후 기본 동작으로 복구됨 (12-02)
원인: C 표준이 유지 여부를 정하지 않음
처방: 유지/1회를 코드로 명시: sigaction(SA_RESETHAND 여부). signal 안 signal() 재등록은 경합 구간 존재
상세 보기
UNIX 계열은 한 번 등록하면 계속 유지되지만, 일부 구현(Windows 등)은 한 번 처리한 뒤 기본 동작으로 되돌아간다. 같은 코드가 환경에 따라 다르게 동작하므로 이식성이 낮다. sigaction은 동작이 정의돼 있다.
강의 코드는 main 루프에서 raise(SIGUSR1)로 자기 자신에게 시그널을 보내 핸들러가 몇 번 호출되는지 확인한다. 핸들러가 계속 등록돼 있다는 가정이 성립해야 동작하는 코드다.
교육자료(12-2)는 “유지될지/한 번만 동작할지”를 코드가 명시하라고 정리한다.
원하는 동작
signal()
sigaction()
핸들러 유지
구현마다 다름 (Linux는 유지, Windows 등은 1회 후 기본 동작 복구). 핸들러 안에서 다시 signal(signum, handler)를 부르면 복구와 재등록 사이에 기본 동작으로 처리되는 구간이 생김
sa_flags = 0 (SA_RESETHAND가 없으면 유지)
1회만 동작
#ifndef WINDOWS로 핸들러 안에서 signal(signum, SIG_DFL)
sa_flags = SA_RESETHAND
sigaction은 C 표준이 아니라 POSIX라서 Windows에서는 쓸 수 없다 (이식성은 signal, 동작의 명확함은 sigaction).
핸들러 안에서 signal()을 불러도 안전한 경우 (핸들러가 유지되는 플랫폼에서, 12-5): 시그널을 무시(SIG_IGN)하도록 등록, 기본 동작(SIG_DFL) 복구, 다른 핸들러로 교체.
같은 핸들러를 재등록하는 호출은 유지되는 플랫폼에서는 의미가 없고, 유지되지 않는 플랫폼에서는 위의 경합이 생긴다. 그래서 재등록이 필요하면 처음부터 sigaction을 쓴다.
핸들러 안에서 하면 안 되는 것 (12-03, 12-04)
요약
문제: 핸들러 안의 fputs/free/strcpy로 교착 또는 해제된 메모리 접근 (12-03, 12-04)
원인: 핸들러는 어느 지점에서든 끼어들고, printf·malloc 계열은 async-signal-safe가 아님
요구사항: 무한 루프에서 count를 올리고 1초 뒤 파일에 저장한다. SIGINT는 무시(메시지만), SIGTERM에만 종료한다.
v1의 문제
요약
문제: busy loop, int 오버플로, fd == 0 검사, 핸들러 안 printf, fdopen 실패 시 close 누락
원인: 1초 대기와 open 실패 값(-1)을 잘못 처리
처방: 1초 대기 후 증가, unsigned long, fd < 0, write, sigaction, 1초마다 저장
상세 보기
처음 코드
문제
처방
while(g_stop_sig){ count++; }
1초 대기 없이 도는 busy loop (CPU 100%), 초당 수억 번 증가
1초 대기 후 증가
int count
busy loop에서는 금방 signed 오버플로(미정의 동작)
unsigned long
종료할 때만 파일에 씀
SIGKILL·크래시 시 값이 사라짐
1초마다 저장
if(fd == 0)
open 실패는 -1. 실패를 못 잡고 fdopen(-1)이 실패 (O_EXCL로 파일이 이미 있으면)
if(fd < 0) + perror
핸들러 안 printf
async-signal-safe 아님
write(STDOUT_FILENO, ...)
signal()
이식성
sigaction
fdopen 실패 시 close(fd) 없음
fd 누수
close(fd)
NOTE
파일이 있으면 새로 만들고 싶을 때는 O_TRUNC를 쓴다 (O_WRONLY | O_CREAT | O_TRUNC, fopen의 "w"와 같다). O_EXCL은 반대로 있으면 실패한다. 단, O_TRUNC는 기존 내용을 바로 지우므로 중요한 파일은 임시 파일 + rename이 안전하다.
v2의 핵심 — sleep이 시그널에 깨어나는 문제
요약
문제: sleep/nanosleep이 시그널로 일찍 깨어나 카운터가 빨라짐
원인: SIGINT를 무시해도 대기 시간은 줄어듦
처방: EINTR이면 rem으로 남은 시간만큼 다시 대기, SIGTERM이면 중단. SA_RESTART 미사용
결과: SIGINT 2번에도 생존, 5.3초 시점에 count.txt가 5
상세 보기
sleep/nanosleep은 시그널이 오면 일찍 깨어난다. SIGINT를 무시하고 싶어도 대기가 1초보다 짧아지면 카운터가 빨라진다. nanosleep이 돌려주는 남은 시간으로 다시 자면 된다.
static int wait_one_sec(void){ struct timespec req = { 1, 0 }, rem = { 0, 0 }; while(nanosleep(&req, &rem) != 0){ if(errno != EINTR) return -1; if(g_stop) return 0; // SIGTERM 이면 대기를 중단 req = rem; // SIGINT 등은 남은 시간만큼 다시 대기 } return 0;}
sigaction은 SA_RESTART를 쓰지 않아서 대기가 시그널에 깨어나고, 핸들러는 flag만 세운다.
쓰기는 fseek(fp, 0, SEEK_SET) → fprintf → fflush로 같은 파일에 최신 값을 덮어쓴다 (값이 커지기만 해서 자릿수가 줄지 않으므로 truncate가 필요 없다).
종료 시 fclose 반환값까지 확인한다.
확인: SIGINT를 두 번 보내도 프로세스는 살아 있고 대기 시간이 줄지 않았다 (5.3초 시점에 count.txt가 5). SIGTERM은 마지막 값을 저장하고 종료 코드 0으로 끝났다.
6. 실습 4 — 시그널로 운영되는 카운터 sig_ex2.c
요구사항: 1초마다 카운터를 interval씩 증가.
SIGHUP은 설정파일(12-07.conf)에서 interval을 다시 읽고, SIGUSR1은 카운터·interval·시그널별 처리 횟수를 출력, SIGUSR2는 카운터를 0으로, SIGTERM은 최종값을 출력하고 종료, SIGINT는 무시한다.
주의:sigaction과 시그널 마스크 사용, 핸들러는 flag만, sleep 중 시그널이 오면 바로 처리하되 카운터는 1초에 한 번만, 설정파일이 없거나 값이 잘못되면 에러 메시지를 출력하고 기존 interval 유지.
첫 구현의 문제
문제 표 (8가지)
처음 코드
문제
처방
char *g_buffer[MAX_SIZE]
문자 버퍼가 아니라 포인터 배열 → fgets, atoi에 incompatible pointer
char g_buffer[MAX_SIZE]
시작할 때 open("12-07.conf", O_WRONLY|O_CREAT|O_TRUNC)
설정파일을 빈 파일로 만들어 버림. 쓰기 전용 fd에 fdopen(fd, "r")도 항상 실패
SIGHUP이 올 때마다 fopen(path, "r")로 읽기
파일을 열어 둔 채 fgets 1회
두 번째 HUP부터 EOF
매번 새로 열고 닫음
if / else if 사슬
동시에 온 시그널은 한 번에 하나만 처리
각각 독립된 if, flag는 처리 직전에 먼저 0으로
while(!s_TERM_f) 안의 else if(s_TERM_f)
도달할 수 없는 코드 → 종료 시 최종값 출력 안 됨
루프 밖에서 final counter 출력
atoi(g_buffer)
abc는 0, 0이나 음수 interval이면 카운터 정지
strtol + 끝 문자 + val > 0 검증, 실패 시 기존 값 유지
핸들러 인자 signo 미사용
-Wextra 경고
(void)signo;
close, sleep 선언 없음
unistd.h 누락
#include <unistd.h>
// 설정 읽기: 실패하면 -1, 호출한 쪽이 기존 interval 을 유지val = strtol(g_buffer, &end, 10);if(end == g_buffer || (*end != '\n' && *end != '\0') || errno == ERANGE || val <= 0 || val > INT_MAX){ fprintf(stderr, "%s: invalid interval\n", path); return -1;}
v2 — 마스크와 pselect로 “즉시 반응, 증가는 1초에 한 번”
요약
문제: sleep(1)이 시그널마다 깨어 카운터가 올라가고, flag 확인 직후 온 시그널은 1초 뒤에 처리
원인: 깨어난 이유(시각 도달/시그널)를 구분하지 않고 flag 확인과 sleep 사이에 틈이 있음
처방: 평소엔 sigprocmask로 차단, 대기 중에만 pselect로 해제(해제와 대기가 원자적). 다음 증가 시각을 CLOCK_MONOTONIC 절대 시각으로 관리
결과: USR1 5번 즉시 출력, INT 10번에도 카운터 정상, SIGTERM 약 14ms에 종료
flowchart TD
A["sigprocmask(SIG_BLOCK)<br/>평소에는 시그널 차단"] --> B["flag 처리<br/>block 구간이라 안전"]
B --> T{"SIGTERM flag?"}
T -- "예" --> Z(["final counter 출력 후 종료"])
T -- "아니오" --> P["pselect(next, orig_mask)<br/>마스크 해제 + 대기를 한 번에"]
P --> R{"깨어난 이유"}
R -- "시각 도달" --> I["counter += interval<br/>next += 1초"]
R -- "시그널" --> K["next 그대로 유지"]
I --> B
K --> B
상세 보기
문제: sleep(1)을 쓰면 시그널이 깨우는 순간 곧바로 current_cnt += interval이 실행되어 시그널마다 카운터가 올라간다. 또 flag를 확인한 뒤 sleep에 들어가기 직전에 온 시그널은 1초 뒤에야 처리된다.
해결: 다섯 시그널을 sigprocmask로 평소에는 막아 두고, 대기하는 동안에만 pselect로 푼다. pselect는 “마스크 해제 + 대기 시작”을 한 번에 하므로 그 사이에 오는 시그널을 놓치지 않는다. 다음 증가 시각은 CLOCK_MONOTONIC 절대 시각(next)으로 관리한다.
sigprocmask(SIG_BLOCK, &block_mask, &orig_mask); // 평소엔 시그널 blockwhile(1){ /* flag 처리 (block 구간이라 안전하게 읽고 지울 수 있다) */ ... if(s_TERM_f){ tCnt++; break; } rc = wait_until(&next, &orig_mask); // pselect(…, &remain, orig_mask) if(rc == 1){ // 시각에 도달했을 때만 증가 current_cnt += interval; next.tv_sec += 1; } // rc == 0 이면 시그널로 깨어남: next 는 그대로, flag 만 처리하고 남은 시간만 다시 대기}
시험
결과
USR1을 0.05초 간격으로 5번
지연 없이 즉시 출력, current는 0 유지
약 1.9초 시점의 USR1
current 1 (틱 1번 경과)
INT 10번
skip 10번, 이후 카운터는 정상 (빨라지지 않음)
HUP(5)·USR2·USR1 동시
모두 처리, 이후 틱마다 +5
SIGTERM
약 14ms 만에 종료, final counter 출력
NOTE
한꺼번에 온 시그널은 코드에 쓴 if 순서대로 처리된다 (HUP → USR1 → USR2 → INT). 그래서 USR2와 USR1이 같이 오면 USR1은 리셋 전 값을 출력한다.
✅ 정리 — 환경변수·시그널 점검 체크리스트
체크리스트 펼치기
환경변수는 외부 값으로 보고 NULL·길이를 검사한 뒤 쓰는가 (getenv → 복사)
getenv 반환 문자열을 직접 수정하지 않는가 (strdup 복사본에서 수정)
strtok을 빈 항목이 의미 있는 문자열에 쓰지 않았는가 (a::b → strchr/strsep)
항목 수 = 구분자 수 + 1로 계산했는가
open에 접근 모드(O_RDONLY/O_WRONLY/O_RDWR)를 줬고 open은 < 0, fdopen은 NULL을 검사했는가
fgets 뒤에 개행을 제거했고, 반환값 NULL(EOF)로 루프를 끝내는가
atexit 핸들러가 필요한 전역을 참조하고, 종료를 return/exit로 하는가 (_Exit, abort는 핸들러 미실행)